fix(objectql): the validation-message bridge negotiates the locale through the one rule every other consumer uses - #16087
Conversation
…rough the one rule every other consumer uses `ExecutionContext.locale` is the `Accept-Language` header's first tag verbatim — `preferredLocaleFromHeader` reports what was ASKED FOR and expands nothing, deliberately. The engine handed that tag straight to `II18nService.t()`, making the write-path message bridge the one consumer that never negotiated: a served adapter resolves a locale exactly and then falls to its declared fallback (`FileI18nAdapter.t()` is `resolveFromLocale(key, locale)` then `resolveFromLocale(key, fallbackLocale)`), so a bare `zh` missed a `zh-CN` bundle and the caller read an English refusal — on the same response whose dataset, view and object labels were Chinese, because those go through `pickData` and `pickData` negotiates. `validationMessageContext` now resolves the requested tag against what the bridged service reports it holds (`getLocales()`), using `@objectstack/spec`'s `resolveBundleLocale` — the SAME rule `pickData` runs for every document translator. The rule is not re-implemented here: the translators ask it about a bundle's keys, this asks it about the service's locales. Passes the tag through untouched when there is nothing to negotiate against — no service, no `getLocales`, an empty/non-array/throwing answer, or no match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
The four-row table now also reads the MACHINE-READABLE half of the envelope for every header: same `code`, same `field`, same refusal, and a satisfying record still accepted in every locale. That turns "no request is newly accepted or rejected" from an assertion into a measurement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…lidation-message-locale-fallback
…ne.ts line shift Pure line rot from this branch's insertion into `packages/objectql/src/engine.ts`, repaired by the gate's own `node scripts/check-system-context-census.mjs --fix`. Line-number anchors only; no prose, no row, no behaviour. Control: with `engine.ts` swapped to the merge base and every other file left at this head, `check-system-context-census` exits 0 — so the shift is the sole cause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…lidation-message-locale-fallback
…ed tree `content/docs/permissions/system-context.mdx` is an os-regen artifact: the merge driver resolved it with exit 0 while silently keeping one side, so it is regenerated from the merged tree with the repo's own tooling (`pnpm gen:system-context-census`) rather than hand-reconciled. Blast radius measured against `origin/main` rather than assumed: 65 rows before and 65 after, and every changed line is the same line with a different `packages/objectql/src/engine.ts` line number — no row dropped, no prose moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…lidation-message-locale-fallback
…tree The merge with `origin/main` brought `share-link-service.ts` line moves into the census while this branch's `engine.ts` change had moved fifteen anchors of its own. `content/docs/permissions/system-context.mdx` is routed to the `os-regen` merge driver precisely because a text merge of the two cannot be right; regenerated from the merged tree, both sides' anchors are present. `check:system-context-census` verdict on the result: OK — 105 elevation read sites in 19 packages across 44 files, all anchored; 140 anchors resolve, 27 declared non-read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e8d3f6c4e79a1c83b41849e9d62ab5a0d7ff4381 && git checkout e8d3f6c4e79a1c83b41849e9d62ab5a0d7ff4381
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dcad825d46a0d42a0fb3a90fb9bfb97898d3c86e 2634da48b127296e4e3c636f0c6309eacbc0f061 && git checkout -B drift-repro dcad825d46a0d42a0fb3a90fb9bfb97898d3c86e && git merge --no-ff 2634da48b127296e4e3c636f0c6309eacbc0f061
node scripts/docs-audit/affected-docs.mjs --json dcad825d46a0d42a0fb3a90fb9bfb97898d3c86e
|
|
Contract review (clause ②) — PASS — PR #16087 at head
|
| # | claim | reading | verdict |
|---|---|---|---|
| 1 | One negotiation rule in the repo: resolveBundleLocale (spec/system/i18n-resolver.ts), reached by every document translator via pickData; the validation-message bridge was the one consumer handing the raw header tag to t() |
Card evidence (same server, same bundle, four rows, three controls; cross-path control on the dataset endpoint) plus triage's anchors. | correct |
| 2 | The fix routes validationMessageContext's locale through resolveBundleLocale(offered, requested) where offered = the bridged service's getLocales(); ⛔ no third rule in objectql |
Diff read: negotiatedMessageLocale builds a key-set record from getLocales() and asks the spec rule — the same question about a different available set. Pass-through on no service / no getLocales / empty / non-array / throw / no match. |
correct |
| 3 | One producer, four consumers (engine.ts:9914/:10353/:11505/:12942) — both authoredRuleMessage and renderValidationMessage follow |
Single seam; preferredLocaleFromHeader untouched. |
correct |
| 4 | Pin: four-row table built whole then asserted; ablation 3 red / 3 green with the right split | Discriminating direction; the envelope-invariance pin is the limb-2 evidence. | correct |
| 5 | Census page regenerated via os-regen-merge.sh after the main merge, both sides' anchors surviving |
os-regen path handled with the repo's tool; check:system-context-census OK. |
correct |
| 6 | Docs: content/docs/ui/translations.mdx already promised variant expansion for this key — the code was the exception; no doc edit |
Corroborated reading; silence elsewhere is not falsehood. | accepted |
| 7 | Recovery round replaced an unsupported "87 families all exit 0" claim with a declared narrowing (18 targeted gates run) | The right correction; CI ran the farm: 35 success / 0 red. | accepted |
② semver
@objectstack/objectql minor — additive accept-set widening on a published method. Correct.
③ Boundary flags
- i18n:
@objectstack/core's exportedresolveLocaleis a verbatim second copy of@objectstack/spec'sresolveBundleLocale— one rule, two places to edit #16085 (memory-i18n.ts'sresolveLocale— a second copy of the same four steps) filed, not folded. Correct; it is the residue of the very disease this card removes and belongs in the engine lane. getLocales()is now consulted on every validation-message context build;FileI18nAdapter.getLocales()is a synchronous read of loaded keys. No performance flag.
Evidence and landing
Checks on 2634da48: 35 success / 2 skipped / 0 red; check-governed-merges --test on the 4 paths: 0 hits — ordinary queue landing. Clearing, same stroke: needs:contract-review off #15757 and PR #16087 with provenance; then check-clause2-carriers --pair 16087 ⇒ ready + auto-merge SQUASH from this seat.
Generated by Claude Code
…ed tree The merge brought in #16087, which inserted its own lines into `engine.ts` and re-anchored this page for them. Both sides had edited this `merge=os-regen` artifact, so the driver merged it with exit 0 while silently keeping one side; `scripts/pm/os-regen-merge.sh` took main's bytes and this commit re-derives the page from the merged tree with `pnpm gen:system-context-census`. Blast radius measured, not assumed: 105 rows before and 105 after, row SET identical once integers are normalised, 12 changed lines and all 12 identical apart from line numbers — no row dropped, none added, no prose moved. The deltas (+9/+12/+13/+14) are this branch's own cumulative insertion offsets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Fixes #15757
accept-language: zhwas answered with an English refusal on the same response whose dataset, view and object labels were already Chinese. This routes the one path that never negotiated through the negotiation rule every other consumer already runs.The mechanism
@objectstack/spechas exactly one locale-negotiation rule,resolveBundleLocale(packages/spec/src/system/i18n-resolver.ts:284): exact match, then case-insensitive, then base language, then variant expansion — the step that reaches azh-CNbundle from a barezh.pickDatacalls it, and every document translator (translateObject,translateView,translateDataset, ...) goes throughpickData. That is why an app shipping onlyzh-CNstill answered a barezhwith translated labels.The write path's message bridge did not.
ExecutionContext.localeis the header's first tag verbatim —preferredLocaleFromHeaderreports what was asked for and expands nothing, deliberately, because each of its callers negotiates differently — andObjectQL.validationMessageContexthanded that tag straight toII18nService.t(). A served adapter resolves a locale exactly and then falls to its declared fallback (FileI18nAdapter.t()isresolveFromLocale(key, locale)thenresolveFromLocale(key, fallbackLocale), with no variant step). Sozhmissed thezh-CNbundle and the Englishrule.messagecame back fromauthoredRuleMessage(packages/objectql/src/validation/rule-validator.ts:2458).The change
validationMessageContext(packages/objectql/src/engine.ts:6085) now resolves the requested tag against what the bridged service reports it holds (II18nService.getLocales()), through that sameresolveBundleLocale.One seam, four call sites.
validationMessageContextis the single producer ofmessages.locale, consumed atengine.ts:9914,:10353,:11505and:12942. Fixing the producer fixes both consumers of that value at once —authoredRuleMessage'sobjects.OBJECT._validations.RULE.messagelookup andrenderValidationMessage'svalidation.field.*deployment-override hook, which was passed the un-negotiated tag for the same reason.The rule is not re-implemented in
packages/objectql— that would be a third negotiation rule, which is the disease this card exists to remove. The document translators askresolveBundleLocaleabout a bundle's keys; this asks it about the service's locales. One rule, two questions.The tag passes through untouched whenever there is nothing to negotiate against: no service, no
getLocales, an empty / non-array / throwing answer, or a tag no variant of which is on offer.preferredLocaleFromHeaderis untouched, and so is every other caller of it.The evidence table, rebuilt in-repo
packages/objectql/src/engine-validation-locale-negotiation.test.ts. One bundle feeds both paths —t()addresses it by dot-notation key and the document translator reads the same nested locations out of the same object — so this is a control, not two unrelated readings. The i18n service is shaped afterFileI18nAdapter.t()(requested locale exactly, then the declared fallback, then the key echoed back), and each row is driven from the raw header throughpreferredLocaleFromHeader.accept-languagezh-CNzh-CN,zh;q=0.9zhenThree of the four rows are controls.
zh-CNandzh-CN,zh;q=0.9prove the path itself works — the key is present, the bundle is loaded;enproves the fallback is right. Only barezhwas the defect.Cross-path control, same header, same bundle: the document translator returns the Chinese field labels for
zhboth before and after. Before the fix, the same server, the same bundle and the same header produced opposite answers on the two paths — that is what turned "two negotiation rules" from an inference into a fact.The table is built as a whole before it is asserted, so all four rows are reported by one run: a row that stops the test is a row whose controls were never read.
Ablation of the pin
One shell, absolute paths,
trap ... EXIT INT TERM, against a clean committed tree. The suite resolves the mutated file as source (a relative./engineimport; this package'svitest.config.tsdeclares no alias), so no rebuild is owed on either leg.grep -c -F, and the blob moved333fce685…→6adbe9c5a…. A run that cannot prove this exits 90 and voids the reading rather than reporting a green.vitestexit 1,Test Files 1 failed (1)/Tests 3 failed | 3 passed (6). The three that failed are exactly the locale-negotiating ones; the three that held are the envelope-invariance, no-match and no-getLocalespins, which is the right split — those three do not measure this fix.333fce685…== HEAD blob,git diff HEADempty,git status --porcelainempty.Clause-② — both limbs, measured separately
Instrument: build at head, swap
packages/objectql/src/engine.tsback toorigin/main, rebuild, diff every declaration file the package publishes — resolved fromexports(.and./core) plusfiles: ["dist"], which is six files, not one — then restore byte-exact and prove it.setI18nServiceappears in neitherdist/index.d.tsnordist/core.d.ts: it is in the shared chunkdist/util-*.d.tsthat both entry points re-export and that ships underfiles. Both barrels did differ, but only in the content-hashed chunk name they import from (util-D3s8yRSa→util-DwjDS0EK), with byte-identical export lists — a delta that carries no information on its own.Limb 1 — does any exported symbol or signature move? YES, additively. In the shared chunk, on the exported
ObjectQLclass:plus
private negotiatedMessageLocale;, which is not callable. Nothing exported was removed or renamed and no return type moved; the parameter's accept set strictly widened, so nothing that typechecked before stops typechecking.II18nServicehas always requiredgetLocales(), so every real service already satisfies it — the member is optional here only because the setter has always accepted a partial shim.Limb 2 — is any request newly accepted or rejected? NO — measured, not asserted. A pin reads the machine-readable half of the envelope for every header: same
code(rule_violation), samefield(return_note), refused in every locale includingde; and a record that satisfies the rule is still accepted inzh-CN,zh,enandde. Only the sentence's language moves — an answer changing inside an already-refused response, not an accept set moving.⇒ Clause-② is
yeson limb 1.needs:contract-reviewis on both carriers and the PR stays draft. Note this is the opposite of the dispatching seat's own non-binding prediction (no, "no exported symbol or signature should move"); measurement is what caught it, which is why the round re-derives rather than inherits.@objectstack/specis not touched (the three-dot change set against the merge base is four files, none underpackages/spec), so no spec-side artifact regeneration or ablation is owed.Documentation drift
Read at this head; per-page verdict, and no page needed an edit.
content/docs/ui/translations.mdx— corroborated, and it is the page the code contradicted. Its "How a locale is chosen" section states the rule generally: "matching walks: exact (zh-CN) → case-insensitive → base language (zh-CN→zh) → variant expansion (zh→zh-CN)". The same page's key table lists the custom validation-rule message key as translatable, and its "Honest limits" section describes the write path swapping the whole sentence. So the documented contract already promised variant expansion for this key; the validation-message path was a silent exception to the page's own stated rule. The fix removes the exception. No edit — writing new prose here would be widening.content/docs/protocol/kernel/i18n-standard.mdx— untouched. Its "Locale Fallback" section documents only the narrowing direction (de-AT→de, then the declared fallback) and says nothing about variant expansion or about the validation-message path. Silence, not falsehood; and it already links to the translations page for the fuller rule, so the two stay consistent.content/docs/api/client-sdk.mdx,content/docs/api/wire-format.mdx,content/docs/protocol/kernel/index.mdx— untouched. Each says only thatAccept-Languagecarries the locale; none makes a claim about how a tag is matched to a bundle.content/docs/references/**is generated andcontent/docs/releases/**is release-owned; neither carries a negotiation claim and neither is edited here.The only documentation file in this diff is
content/docs/permissions/system-context.mdx, and only as line-number anchors, below.Merge, and the census
This branch merged current
origin/mainthroughscripts/pm/os-regen-merge.sh, the sanctioned sequence: the census page is routed to themerge=os-regendriver in.gitattributes, so a text merge of it cannot be right. Both sides had moved it —mainre-anchoredshare-link-service.tsrows, this branch'sengine.tsinsertion moved fifteenobjectql/src/engine.tsanchors — so the script took main's side and the census was regenerated from the merged tree. Verified afterwards that both sides survived: main'sshare-link-service.ts:459row and this branch'sengine.ts:11733/:10381/:14981anchors are all present. Nothing but line numbers changed — no prose, no row, no behaviour, and nothing undercontent/docs/releases/.pnpm check:system-context-censusverdict on the result:OK — 105 elevation read sites in 19 packages across 44 files, all anchored; 140 anchors resolve, 27 declared non-read.Verification
Gate family re-derived after the final commit with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack; its provenance line confirms the answer comes from this repo's tree at2634da48b, over the three-dot change set against merge base1157e7b72. It names 87 distinct families.Declared narrowing: 87 families were not run locally — CI runs that farm exactly once, and it has. What was run at
2634da48b, with each exit code captured before any pipe (cmd > log 2>&1; EXIT=$?) and each gate's own verdict line read rather than a bare$?:pnpm --filter @objectstack/objectql exec vitest runover the new suite plusrule-validator.test.tsandrecord-validator.test.ts—Test Files 3 passed (3),Tests 289 passed (289).pnpm --filter @objectstack/objectql typecheck— green across all three legs. Worth stating precisely: leg 1 (tsc --noEmit, the build config) does not compile the new test file —--listFilesputsengine.tsin the program and the test file at 0 hits, because that config excludes tests deliberately. The leg that covers it ischeck:test-typecheckagainsttsconfig.test.json, whose verdict isOK — @objectstack/objectql's test layer compiles ... 44 file(s) / 242 error(s) / 69 pinned signature(s) held, unchanged. So "typecheck is green" is a claim about the test file only via that third leg.nul-bytes,engine-double-contract,objectql-double-limit,where-matcher,test-source-alias,cross-package-test-inputs,system-context-census,merge-driver,type-check-coverage,durability-log-level,swallow-census-controls,stack-collection-maps,changeset-gate-self-tests,objectui-changeset,doc-anchors,docs-single-h1,corpus-claim-drift,adr-0087-registration.Heavy steps ran through
scripts/pm/os-verify-lock.sh; every wall-clock figure it printed is a shared-box reading, not an idle-box one.Out of scope, filed separately
packages/core/src/fallbacks/memory-i18n.ts:47exportsresolveLocale(requestedLocale, availableLocales[])— a behaviourally identical second copy ofresolveBundleLocale(same four steps, same order), list-shaped instead of record-shaped. It produces the same answers, so it is a maintenance hazard rather than a live defect, and collapsing it is a different package and a different change. Filed as #16085.Generated by Claude Code